Skip to content

二进制协议设计

基于 rts-server-golang wire/ 模块解析 关键词: 二进制协议, 消息类型, 手写codec, 小端序, Protobuf对比

概述

游戏消息协议设计在 UDP 可靠传输层之上(L4),是客户端与服务端通信的语言。

本项目不使用 Protobuf,而是手写二进制编解码,原因是:

  • 无需外部依赖
  • 完全掌控每个字节
  • 适合游戏这种固定格式、高频小消息的场景
  • 学习目的:理解协议本质

消息类型总览

ID消息方向用途
1HelloC→S协议版本协商
2HelloAckS→C版本确认
3JoinRoomC→S加入房间
4JoinAckS→C加入结果
10CmdC→S玩家命令
11FrameBundleS→C封帧广播
12HashAckC→SHash 上报
13RTTReportC→SRTT 上报
14NPubS→CN 变更通知
15FeedbackHintS→C即时反馈(非帧同步)
20ResumeC→S断线重连
21ResyncS→C重连同步数据
30Bye双向断开连接

核心消息详解

Hello / HelloAck — 握手

go
type Hello struct {
    ProtocolVersion uint16
    PlayerName     string  // max 32 bytes
}

type HelloAck struct {
    ProtocolVersion uint16
    ServerTickRate  uint16
    Accepted        bool
}

为什么需要版本协商?

客户端和服务端的 wire 协议版本必须匹配,否则编解码会错乱。Hello 带客户端版本,HelloAck 告知是否接受:

go
ack.Accepted = hello.ProtocolVersion == wire.ProtocolVersion

JoinRoom / JoinAck — 加入房间

go
type JoinRoom struct {
    RoomID string  // 空=创建新房
}

type JoinAck struct {
    RoomID   string
    PlayerID uint8   // 服务器分配
    Seed     uint64  // 世界 seed(关键)
    MapW     int32
    MapH     int32
    Accepted bool
}

Seed 的作用:服务端通过 JoinAck.Seed 告知客户端世界 seed,客户端据此初始化确定性 PRNG。

Cmd — 玩家命令

go
type Cmd struct {
    Tick      uint32   // 在哪一帧执行
    Player    uint8    // 谁发的
    Op        uint8    // Move=1 / Attack=2 / Stop=3
    UnitID    uint32   // 操作哪个单位
    TargetX   int32    // 目标位置(原始 fixed 值)
    TargetY   int32
    TargetID  uint32   // 攻击目标
}

定长 22 字节(不含 type byte):

type(1) + tick(4) + player(1) + op(1) + unitid(4) + tx(4) + ty(4) + targetid(4) = 23

FrameBundle — 封帧广播

服务端将同一帧所有玩家的命令打包广播:

go
type FrameBundle struct {
    Tick     uint32  // 帧号
    NCurrent uint8   // 当前 N 值
    Cmds     []Cmd   // 该帧所有命令
}

客户端收到后按 tick 缓存,本地执行。

HashAck — Desync 校验

go
type HashAck struct {
    Tick uint32
    Hash uint64  // 执行完该帧后的 world hash
}

每帧执行完客户端上报 hash,服务端 HashAgg 对比。

RTTReport — RTT 采样

go
type RTTReport struct {
    Samples [3]uint16  // 最近 3 个 RTT 样本(毫秒)
}

客户端主动上报 RTT,供 AdaptiveN 计算最大 RTT。

NPub — N 变更通知

go
type NPub struct {
    EffectiveFromTick uint32  // 从哪帧开始生效
    N                 uint8   // 新 N 值
}

服务端通知客户端 N 变了,客户端据此调整输入延迟。

Resume / Resync — 断线重连

go
type Resume struct {
    ConnID           uint16
    LastExecutedTick uint32
    Token            [16]byte  // 重连令牌
}

type Resync struct {
    HasSnapshot bool
    Snapshot    []byte         // 全量快照(落后太多时)
    Frames      []FrameBundle  // 增量帧(追赶时)
}

编码设计

固定格式 + 小端序

go
// 小端序:符合大多数 CPU 内存布局,编解码简单
binary.LittleEndian.PutUint32(buf[0:4], value)
value = binary.LittleEndian.Uint32(buf[0:4])

变长字符串处理

字符串用 长度前缀

go
// 写入
nameLen := byte(min(len(name), 32))
buf := make([]byte, 1+nameLen)
buf[0] = nameLen
copy(buf[1:], name)

// 读取
nameLen := int(data[0])
name := string(data[1:1+nameLen])

空值处理

go
// Accepted: bool → 0=false, 1=true
if m.Accepted {
    buf[5] = 1
} else {
    buf[5] = 0
}

// 读: data[4] != 0 即为 true

手写编解码的权衡

优点

  • 无外部依赖
  • 每个字节完全可控
  • 高频小消息性能好
  • 学习协议设计本质

缺点

  • 字段增减要改两边代码
  • 无 schema 校验,容易写错
  • 多语言 SDK 要各自实现

实际项目建议

对于生产游戏服务端

  • 小消息用 手写二进制(游戏协议,字段少)
  • 大消息用 Protobuf(复杂结构,跨语言)
  • 本项目的做法是教学目的,生产中可以用 hybrid 方案

与 Protobuf 对比

维度手写二进制Protobuf
字段增减需改编解码改 schema 即可
跨语言各语言重写自动生成
压缩率更高(无 tag)略低(有 tag)
校验schema 校验
复杂度低(无依赖)高(需 proto 工具链)

相关

撰写